SELECT FOR UPDATE를 사용할 때 주의할 점

SELECT FOR UPDATE를 사용할 때 주의할 점

한눈에 보기

SELECT ... FOR UPDATE는 조회한 데이터를 기준으로 이어지는 변경을 안전하게 수행하기 위한 잠금 읽기다. 하지만 쿼리에 문구를 붙였다고 동시성 문제가 자동으로 해결되지는 않는다. 반드시 트랜잭션 안에서 사용해야 하고, 검색 조건과 인덱스에 따라 예상보다 넓은 범위가 잠길 수 있으며, 잠근 상태에서 느린 작업을 하면 전체 처리량이 급격히 떨어질 수 있다.

목차

문제가 되는 상황

재고를 예약하는 코드를 먼저 읽고 나중에 수정하는 두 단계로 작성했다고 하자.

async function reserve(productId: number, quantity: number) {
  const product = await productRepository.findById(productId);

  if (product.stock < quantity) {
    throw new OutOfStockError();
  }

  await productRepository.updateStock(
    productId,
    product.stock - quantity,
  );
}

재고가 1개 남았을 때 요청 A와 B가 거의 동시에 조회하면 둘 다 stock = 1을 읽고 검사를 통과한다. 이후 둘 다 0을 저장하면 DB에 음수 재고는 남지 않더라도 상품 한 개로 두 건의 예약이 성립한다.

sequenceDiagram
    participant A as 요청 A
    participant DB as Database
    participant B as 요청 B
    A->>DB: SELECT stock → 1
    B->>DB: SELECT stock → 1
    A->>DB: UPDATE stock = 0
    B->>DB: UPDATE stock = 0
    Note over A,B: 두 요청 모두 성공으로 판단

문제는 UPDATE 문 자체가 원자적인가가 아니라, 조회한 상태가 UPDATE를 실행할 때까지 유효하다고 가정한 것이다.

일반 SELECT만으로 부족한 이유

InnoDB에서 일반 SELECT는 보통 일관된 읽기(consistent read)를 수행한다. MVCC가 만든 스냅샷을 읽기 때문에 다른 트랜잭션의 쓰기를 막지 않는다. 조회 성능과 동시성에는 유리하지만 “내가 이 값을 기준으로 곧 수정할 테니 아무도 바꾸지 마라”라는 의도는 전달하지 않는다.

START TRANSACTION;

SELECT stock
FROM products
WHERE id = 7;

-- 애플리케이션에서 재고 검사

UPDATE products
SET stock = 0
WHERE id = 7;

COMMIT;

트랜잭션으로 묶었더라도 일반 SELECT가 읽은 값과 UPDATE 사이에 다른 트랜잭션이 같은 행을 바꿀 수 있다. 같은 SELECT를 반복했을 때 같은 스냅샷을 보는 것과 다른 쓰기를 차단하는 것은 전혀 다른 보장이다.

스냅샷 읽기와 잠금 읽기

일반 SELECT는 과거의 일관된 버전을 읽을 수 있다. FOR UPDATE는 현재 버전을 읽고 그 행을 변경할 의도가 있음을 DB에 알린다. 내부 구조는 MySQL InnoDB의 MVCC가 읽기를 처리하는 방법과 연결된다.

SELECT FOR UPDATE가 보장하는 것

잠금 읽기는 조회와 변경을 하나의 임계 구역처럼 만든다.

START TRANSACTION;

SELECT stock
FROM products
WHERE id = :product_id
FOR UPDATE;

UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id;

COMMIT;

첫 번째 트랜잭션이 대상 레코드의 배타적 잠금을 획득하면 경쟁 트랜잭션은 호환되지 않는 잠금이 해제될 때까지 대기한다. 대기 후에는 최신 커밋 상태를 기준으로 재고를 확인하게 된다.

애플리케이션 코드는 조회와 변경이 반드시 같은 DB 커넥션과 같은 트랜잭션에 속하도록 구성해야 한다. 다음은 특정 ORM의 실제 문법이 아니라 경계를 보여 주기 위한 예시다.

await database.transaction(async (tx) => {
  const product = await tx.products.findForUpdate(productId);

  if (!product || product.stock < quantity) {
    throw new OutOfStockError();
  }

  await tx.products.decreaseStock(productId, quantity);
  await tx.reservations.insert({ productId, quantity });
});

반드시 명시적 트랜잭션 안에서 사용한다

자동 커밋 모드에서 SELECT ... FOR UPDATE 한 문장만 실행하면 문장이 끝나는 즉시 트랜잭션도 종료되어 잠금이 풀릴 수 있다. 이어지는 UPDATE는 보호받지 못한다.

// 잘못된 예: 두 메서드가 서로 다른 커넥션을 받을 수도 있다.
const product = await pool.query(
  "SELECT stock FROM products WHERE id = ? FOR UPDATE",
  [productId],
);

await pool.query(
  "UPDATE products SET stock = stock - ? WHERE id = ?",
  [quantity, productId],
);

커넥션 풀 환경에서는 트랜잭션 객체가 제공하는 쿼리 실행기를 끝까지 전달해야 한다.

const connection = await pool.getConnection();

try {
  await connection.beginTransaction();

  const [rows] = await connection.execute(
    "SELECT stock FROM products WHERE id = ? FOR UPDATE",
    [productId],
  );

  if (rows.length === 0 || rows[0].stock < quantity) {
    throw new OutOfStockError();
  }

  await connection.execute(
    "UPDATE products SET stock = stock - ? WHERE id = ?",
    [quantity, productId],
  );

  await connection.commit();
} catch (error) {
  await connection.rollback();
  throw error;
} finally {
  connection.release();
}
repository가 트랜잭션을 우회하지 않는지 확인한다

서비스가 받은 tx 대신 전역 repository를 호출하면 일부 쿼리만 트랜잭션 밖에서 실행될 수 있다. 코드 리뷰에서는 SQL뿐 아니라 커넥션 전달 경로를 함께 봐야 한다.

잠금 범위는 WHERE 절만 보고 결정되지 않는다

“WHERE 조건에 맞는 행만 잠긴다”라고 외우면 위험하다. DB는 쿼리를 실행하면서 접근한 인덱스 레코드에 잠금을 설정한다. 적절한 인덱스가 없으면 많은 레코드를 검사하며 잠금 범위가 커질 수 있다.

SELECT id
FROM reservations
WHERE event_id = 100
  AND status = 'PENDING'
FOR UPDATE;

event_id, status를 선두로 하는 인덱스가 없고 테이블 스캔이 발생하면 의도한 몇 행보다 훨씬 넓은 범위에서 경합이 생길 수 있다. 잠금 쿼리 역시 실행 계획을 확인해야 한다.

EXPLAIN
SELECT id
FROM reservations
WHERE event_id = 100
  AND status = 'PENDING'
FOR UPDATE;

필요한 접근 패턴이 명확하다면 복합 인덱스를 검토한다.

CREATE INDEX ix_reservations_event_status_id
ON reservations(event_id, status, id);

인덱스 추가는 쓰기 비용과 저장 공간을 늘린다. 잠금 범위를 줄인다는 이유만으로 무조건 추가하지 말고 실제 실행 계획과 대기 시간을 비교해야 한다.

행이 없을 때도 생각해야 한다

기본 키 동등 조건으로 존재하는 한 행을 찾는 경우는 비교적 단순하다. 하지만 범위 검색이나 존재하지 않는 키 검색에서는 “조회 결과가 없으니 아무것도 잠기지 않는다”고 단정할 수 없다. InnoDB의 REPEATABLE READ에서는 다른 트랜잭션이 검색 범위에 새 레코드를 삽입하지 못하도록 gap 또는 next-key lock이 사용될 수 있다.

SELECT id
FROM coupons
WHERE campaign_id = 50
  AND serial_number BETWEEN 1000 AND 1999
FOR UPDATE;

결과가 비어 있어도 해당 범위의 삽입과 경합할 수 있다. 반대로 DB 종류와 격리 수준, 유니크 인덱스를 사용한 정확한 검색 여부에 따라 잠금 동작이 달라진다.

잠금을 추측하지 말고 재현한다

세션 A에서 트랜잭션을 연 채 잠금 쿼리를 실행하고, 세션 B에서 경계값 삽입과 인접 행 수정을 시도해 본다. 어떤 쿼리가 기다리는지 확인하는 실험이 문서 한 줄을 외우는 것보다 정확하다.

트랜잭션을 짧게 유지하는 구조

나쁜 흐름은 잠금을 얻은 뒤 외부 결제 API를 호출하는 것이다.

BEGIN
  상품 행 잠금
  결제 API 호출         ← 수백 ms에서 수십 초까지 지연 가능
  주문 저장
COMMIT

네트워크 지연 동안 같은 상품을 사려는 요청이 모두 줄을 선다. 결제사가 타임아웃되면 DB 커넥션과 잠금까지 오래 점유한다. 구조는 도메인에 따라 달라지지만 보통 짧은 재고 예약 트랜잭션과 외부 결제를 분리한다.

1. 짧은 DB 트랜잭션에서 재고를 RESERVED 상태로 변경
2. 트랜잭션 종료
3. 결제 API 호출
4. 성공하면 주문 확정, 실패하면 예약 해제
5. 오래 남은 예약은 만료 작업으로 복구
flowchart LR
    A[재고 잠금 및 예약] --> B[COMMIT]
    B --> C[외부 결제 호출]
    C -->|성공| D[주문 확정]
    C -->|실패| E[예약 해제]
    A -. 잠금 보유 .-> B

이 구조는 보상 처리와 만료 정책이 필요하지만 외부 시스템의 응답 시간이 DB 잠금 시간으로 전파되는 문제를 막는다.

여러 행을 잠글 때는 순서를 통일한다

장바구니에 여러 상품이 있을 때 요청마다 입력 순서대로 잠그면 데드락이 생길 수 있다.

트랜잭션 A: 상품 10 잠금 → 상품 20을 기다림
트랜잭션 B: 상품 20 잠금 → 상품 10을 기다림

모든 코드 경로에서 정렬된 동일한 순서로 잠그는 것이 기본적인 예방책이다.

SELECT id, stock
FROM products
WHERE id IN (10, 20)
ORDER BY id
FOR UPDATE;

그래도 데드락을 완전히 없앨 수는 없다. 인덱스 접근 순서나 다른 테이블 잠금이 얽힐 수 있으므로 애플리케이션은 데드락을 일시적인 동시성 오류로 분류하고 제한적으로 재시도해야 한다. 이 부분은 데드락이 생기는 이유와 재시도 전략에서 다룬다.

NOWAIT와 SKIP LOCKED의 용도

기다리는 것이 항상 정답은 아니다. DB가 지원한다면 NOWAIT로 즉시 실패하거나 SKIP LOCKED로 이미 잠긴 행을 건너뛸 수 있다.

SELECT id
FROM jobs
WHERE status = 'READY'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

여러 워커가 작업 큐를 나눠 가져가는 경우 SKIP LOCKED가 유용하다. 그러나 좌석 예약처럼 사용자가 특정 행을 요구한 경우 잠긴 행을 건너뛰면 의미가 달라진다.

방식 경쟁 행을 만났을 때 적합한 예
기본 FOR UPDATE 잠금 해제까지 대기 짧은 순차 갱신
NOWAIT 즉시 잠금 오류 빠른 실패가 나은 대화형 요청
SKIP LOCKED 잠긴 행을 제외하고 계속 여러 워커의 작업 큐 소비

테스트와 운영 관측

동시성 코드는 실제로 요청을 겹쳐 봐야 검증할 수 있다. 테스트에서는 한 트랜잭션이 잠금을 얻은 것을 확인한 뒤 두 번째 트랜잭션을 시작해 첫 번째 커밋 전에는 완료되지 않는지 검사한다.

it("같은 상품 예약을 직렬화한다", async () => {
  const first = beginReservation({ productId: 7, pauseAfterLock: true });
  await waitUntilLockIsAcquired();

  const second = beginReservation({ productId: 7 });
  expect(await isPending(second)).toBe(true);

  releaseFirstTransaction();

  await expect(first).resolves.toMatchObject({ status: "RESERVED" });
  await expect(second).rejects.toBeInstanceOf(OutOfStockError);
});

운영에서는 잠금 대기 시간의 p95·p99, lock wait timeout 횟수, 오래 열린 트랜잭션, 데드락 로그, 커넥션 풀 대기 시간을 함께 본다. DB 잠금 대기가 길어지면 애플리케이션 커넥션 풀이 먼저 고갈될 수도 있다. 사용자 응답 지연과 DB 내부 지표를 같은 시간축으로 비교해야 원인을 찾기 쉽다.

결론

SELECT ... FOR UPDATE는 읽은 현재 상태를 바탕으로 이어지는 쓰기를 안전하게 만들기 위한 도구다. 올바르게 사용하려면 조회와 변경을 같은 트랜잭션·같은 커넥션에 두고, 대상 조건을 지원하는 인덱스를 확인하며, 잠금 안에서 수행하는 일을 최소화해야 한다.

또한 결과 행의 개수만으로 잠금 범위를 추측해서는 안 된다. 범위 조건, 존재하지 않는 키, 격리 수준에 따라 gap lock이 개입할 수 있다. 잠금은 경쟁을 없애는 것이 아니라 순서를 정하고 기다리게 만드는 방식이다. 대기 시간이 허용 범위를 넘는다면 조건부 UPDATE, 낙관적 락, 예약 상태 분리 같은 다른 설계를 검토해야 한다.

관련 노트